<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
    import { createEventDispatcher, tick } from "svelte";
    import type { Writable } from "svelte/store";
    import { writable } from "svelte/store";

    import StickyContainer from "../../components/StickyContainer.svelte";
    import { Tags, tags as tagsService } from "../../lib/proto";
    import { execCommand } from "../helpers";
    import { TagOptionsButton } from "./tag-options-button";
    import TagEditMode from "./TagEditMode.svelte";
    import TagInput from "./TagInput.svelte";
    import type { Tag as TagType } from "./tags";
    import {
        attachId,
        getName,
        replaceWithColons,
        replaceWithUnicodeSeparator,
    } from "./tags";
    import TagSpacer from "./TagSpacer.svelte";
    import WithAutocomplete from "./WithAutocomplete.svelte";

    export let tags: Writable<string[]>;

    let tagTypes: TagType[];
    function tagsToTagTypes(tags: string[]): void {
        tagTypes = tags.map(
            (tag: string): TagType => attachId(replaceWithUnicodeSeparator(tag)),
        );
    }

    $: tagsToTagTypes($tags);

    const show = writable(false);
    const dispatch = createEventDispatcher();
    const noSuggestions = Promise.resolve([]);
    let suggestionsPromise: Promise<string[]> = noSuggestions;

    function saveTags(): void {
        const tags = tagTypes.map((tag: TagType) => tag.name).map(replaceWithColons);
        dispatch("tagsupdate", { tags });

        suggestionsPromise = noSuggestions;
    }

    let active: number | null = null;
    let activeAfterBlur: number | null = null;
    let activeName = "";
    let activeInput: HTMLInputElement;

    let autocomplete: any;
    let autocompleteDisabled: boolean = false;

    async function fetchSuggestions(input: string): Promise<string[]> {
        const { tags } = await tagsService.completeTag(
            Tags.CompleteTagRequest.create({ input, matchLimit: 500 }),
        );
        return tags;
    }

    const withoutSingleColonAtStartOrEnd = /^:?([^:].*?[^:]):?$/;

    function updateSuggestions(): void {
        const activeTag = tagTypes[active!];
        const activeName = activeTag.name;

        autocompleteDisabled = activeName.length === 0;

        if (autocompleteDisabled) {
            suggestionsPromise = noSuggestions;
        } else {
            const withColons = replaceWithColons(activeName);
            const withoutSingleColons = withoutSingleColonAtStartOrEnd.test(withColons)
                ? withColons.replace(withoutSingleColonAtStartOrEnd, "$1")
                : withColons;

            suggestionsPromise = fetchSuggestions(withoutSingleColons).then(
                (names: string[]): string[] => {
                    autocompleteDisabled = names.length === 0;
                    return names.map(replaceWithUnicodeSeparator);
                },
            );
        }
    }

    function onAutocomplete(selected: string): void {
        const activeTag = tagTypes[active!];

        activeName = selected ?? activeTag.name;
        activeInput.setSelectionRange(Infinity, Infinity);
    }

    async function updateTagName(tag: TagType): Promise<void> {
        tag.name = activeName;
        tagTypes = tagTypes;

        await tick();
        if (activeInput) {
            autocomplete.update();
        }
    }

    function setActiveAfterBlur(value: number): void {
        if (activeAfterBlur === null) {
            activeAfterBlur = value;
        }
    }

    function appendEmptyTag(): void {
        // used by tag badge and tag spacer
        const lastTag = tagTypes[tagTypes.length - 1];

        if (!lastTag || lastTag.name.length > 0) {
            appendTagAndFocusAt(tagTypes.length - 1, "");
        }

        const tagsHadFocus = active === null;
        active = null;

        if (tagsHadFocus) {
            decideNextActive();
        }
    }

    function appendTagAndFocusAt(index: number, name: string): void {
        tagTypes.splice(index + 1, 0, attachId(name));
        tagTypes = tagTypes;
        setActiveAfterBlur(index + 1);
    }

    function isActiveNameUniqueAt(index: number): boolean {
        const names = tagTypes.map(getName);
        names.splice(index, 1);

        const contained = names.indexOf(activeName);
        if (contained >= 0) {
            tagTypes[contained >= index ? contained + 1 : contained].flash();
            return false;
        }

        return true;
    }

    async function splitTag(index: number, start: number, end: number): Promise<void> {
        const current = activeName.slice(0, start);
        const splitOff = activeName.slice(end);

        activeName = current;
        // await tag to update its name, so it can normalize correctly
        await tick();

        appendTagAndFocusAt(index, splitOff);
        active = null;
        await tick();

        if (index === active) {
            // splitOff tag was rejected
            return;
        }
        activeInput.setSelectionRange(0, 0);
    }

    function insertTagKeepFocus(index: number): void {
        if (isActiveNameUniqueAt(index)) {
            tagTypes.splice(index, 0, attachId(activeName));
            active!++;
            tagTypes = tagTypes;
        }
    }

    function deleteTagAt(index: number): TagType {
        const deleted = tagTypes.splice(index, 1)[0];
        tagTypes = tagTypes;

        if (activeAfterBlur !== null && activeAfterBlur > index) {
            activeAfterBlur--;
        }

        return deleted;
    }

    function isFirst(index: number): boolean {
        return index === 0;
    }

    function isLast(index: number): boolean {
        return index === tagTypes.length - 1;
    }

    function joinWithPreviousTag(index: number): void {
        if (isFirst(index)) {
            return;
        }

        const deleted = deleteTagAt(index - 1);
        activeName = deleted.name + activeName;
        active!--;
        updateTagName(tagTypes[active!]);
    }

    function joinWithNextTag(index: number): void {
        if (isLast(index)) {
            return;
        }

        const deleted = deleteTagAt(index + 1);
        activeName = activeName + deleted.name;
        updateTagName(tagTypes[active!]);
    }

    function moveToPreviousTag(index: number): void {
        if (isFirst(index)) {
            return;
        }

        activeAfterBlur = index - 1;
        active = null;
    }

    async function moveToNextTag(index: number): Promise<void> {
        if (isLast(index)) {
            if (activeName.length !== 0) {
                appendTagAndFocusAt(index, "");
                active = null;
            }
            return;
        }

        activeAfterBlur = index + 1;
        active = null;

        await tick();
        activeInput.setSelectionRange(0, 0);
    }

    function deleteTagIfNotUnique(tag: TagType, index: number): void {
        if (!tagTypes.includes(tag)) {
            // already deleted
            return;
        }

        if (!isActiveNameUniqueAt(index)) {
            deleteTagAt(index);
        }
    }

    function decideNextActive() {
        active = activeAfterBlur;
        activeAfterBlur = null;
    }

    function onKeydown(event: KeyboardEvent): void {
        switch (event.code) {
            case "ArrowUp":
                autocomplete.selectPrevious();
                event.preventDefault();
                break;

            case "ArrowDown":
                autocomplete.selectNext();
                event.preventDefault();
                break;

            case "Tab":
                if (!$show) {
                    break;
                } else if (event.shiftKey) {
                    autocomplete.selectPrevious();
                } else {
                    autocomplete.selectNext();
                }
                event.preventDefault();
                break;

            case "Enter":
                autocomplete.chooseSelected();
                event.preventDefault();
                break;
        }
    }

    let selectionAnchor: number | null = null;
    let selectionFocus: number | null = null;

    function select(index: number) {
        tagTypes[index].selected = !tagTypes[index].selected;
        tagTypes = tagTypes;

        selectionAnchor = index;
    }

    function selectRange(index: number) {
        if (selectionAnchor === null) {
            select(index);
            return;
        }

        selectionFocus = index;

        const from = Math.min(selectionAnchor, selectionFocus);
        const to = Math.max(selectionAnchor, selectionFocus);

        for (let index = from; index <= to; index++) {
            tagTypes[index].selected = true;
        }

        tagTypes = tagTypes;
    }

    function deselect() {
        tagTypes = tagTypes.map(
            (tag: TagType): TagType => ({ ...tag, selected: false }),
        );
        selectionAnchor = null;
        selectionFocus = null;
    }

    function deselectIfLeave(event: FocusEvent) {
        const toolbar = event.currentTarget as HTMLDivElement;
        if (
            event.relatedTarget === null ||
            !toolbar.contains(event.relatedTarget as Node)
        ) {
            deselect();
        }
    }

    /* TODO replace with navigator.clipboard once available */
    function copyToClipboard(content: string): void {
        const textarea = document.createElement("textarea");
        textarea.value = content;
        textarea.setAttribute("readonly", "");
        textarea.style.position = "absolute";
        textarea.style.left = "-9999px";
        document.body.appendChild(textarea);
        textarea.select();
        execCommand("copy");
        document.body.removeChild(textarea);
    }

    function selectAllTags() {
        tagTypes.forEach((tag) => (tag.selected = true));
        tagTypes = tagTypes;
    }

    function copySelectedTags() {
        const content = tagTypes
            .filter((tag) => tag.selected)
            .map((tag) => replaceWithColons(tag.name))
            .join("\n");
        copyToClipboard(content);
        deselect();
    }

    function deleteSelectedTags() {
        tagTypes
            .map((tag, index) => [tag.selected, index])
            .filter(([selected]) => selected)
            .reverse()
            .forEach(([, index]) => deleteTagAt(index as number));
        deselect();
        saveTags();
    }

    let height: number;
    let badgeHeight: number;

    // typically correct for rows < 7
    $: assumedRows = Math.floor(height / badgeHeight);
    $: shortenTags = shortenTags || assumedRows > 2;
    $: anyTagsSelected = tagTypes.some((tag) => tag.selected);
</script>

<StickyContainer
    --gutter-block="0.1rem"
    --sticky-borders="1px 0 0"
    bind:height
    class="d-flex"
>
    <div class="tag-editor-area" on:focusout={deselectIfLeave}>
        <TagOptionsButton
            bind:badgeHeight
            tagsSelected={anyTagsSelected}
            on:tagselectall={selectAllTags}
            on:tagcopy={copySelectedTags}
            on:tagdelete={deleteSelectedTags}
            on:tagappend={appendEmptyTag}
        />

        {#each tagTypes as tag, index (tag.id)}
            <div class="tag-relative" class:hide-tag={index === active}>
                <TagEditMode
                    class="ms-0"
                    name={index === active ? activeName : tag.name}
                    tooltip={tag.name}
                    active={index === active}
                    shorten={shortenTags}
                    bind:flash={tag.flash}
                    bind:selected={tag.selected}
                    on:tagedit={() => {
                        active = index;
                        deselect();
                    }}
                    on:tagselect={() => select(index)}
                    on:tagrange={() => selectRange(index)}
                    on:tagdelete={() => {
                        deselect();
                        deleteTagAt(index);
                        saveTags();
                    }}
                />

                {#if index === active}
                    <WithAutocomplete
                        {suggestionsPromise}
                        {show}
                        on:update={updateSuggestions}
                        on:select={({ detail }) => onAutocomplete(detail.selected)}
                        on:choose={({ detail }) => {
                            onAutocomplete(detail.chosen);
                            splitTag(index, detail.chosen.length, detail.chosen.length);
                        }}
                        let:createAutocomplete
                        let:hide
                    >
                        <TagInput
                            id={tag.id}
                            class="position-absolute start-0 top-0 bottom-0 ps-2 py-0"
                            disabled={autocompleteDisabled}
                            bind:name={activeName}
                            bind:input={activeInput}
                            on:focus={() => {
                                activeName = tag.name;
                                autocomplete = createAutocomplete();
                            }}
                            on:keydown={onKeydown}
                            on:keyup={() => {
                                if (activeName.length === 0) {
                                    hide?.();
                                }
                            }}
                            on:taginput={() => updateTagName(tag)}
                            on:tagsplit={({ detail }) =>
                                splitTag(index, detail.start, detail.end)}
                            on:tagadd={() => insertTagKeepFocus(index)}
                            on:tagdelete={() => deleteTagAt(index)}
                            on:tagjoinprevious={() => joinWithPreviousTag(index)}
                            on:tagjoinnext={() => joinWithNextTag(index)}
                            on:tagmoveprevious={() => moveToPreviousTag(index)}
                            on:tagmovenext={() => moveToNextTag(index)}
                            on:tagaccept={() => {
                                deleteTagIfNotUnique(tag, index);
                                if (tag) {
                                    updateTagName(tag);
                                }
                                saveTags();
                                decideNextActive();
                            }}
                        />
                    </WithAutocomplete>
                {/if}
            </div>
        {/each}

        <TagSpacer on:click={appendEmptyTag} />
    </div>
</StickyContainer>

<style lang="scss">
    .tag-editor-area {
        display: flex;
        flex-flow: row wrap;
        padding: 0 1px 1px;
        overflow: hidden;
        margin-bottom: 3px;
    }

    .tag-relative {
        position: relative;
        padding: 0 1px;
    }

    .hide-tag :global(.tag) {
        opacity: 0;
    }
</style>
